Micron Document
🎖️GitЯра🎖️

Commit a828e9d51353c00e8828ed2009b269d0e7a4657a


Parents : d1d44f8
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-25T16:25:22-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-25T21:25:22Z

feat(node): show AQI in the air quality graph and table (#6434)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 4b5efa11bc..3d1596c22b 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -94,7 +94,15 @@ app_settings
app_too_old
app_version
apply
+### AQI ###
aqi
+aqi_good
+aqi_hazardous
+aqi_moderate
+aqi_unhealthy
+aqi_unhealthy_sensitive
+aqi_value_with_severity
+aqi_very_unhealthy
are_you_sure
are_you_sure_change_default
audio

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt
index cd0556ab9d..e8d5868143 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt
@@ -77,6 +77,26 @@ object AirQualityIndex {
}
}
+ /**
+ * The historical NowCast AQI for every reading in [readings] — element `i` is the AQI as of `readings[i]`'s own
+ * timestamp, derived only from readings at or before it, or null where EPA's minimum-data rule (see
+ * [computeNowCastPm25]) isn't met at that point. Used to chart/tabulate AQI over time (issue #6381) rather than
+ * only the single live value.
+ *
+ * [readings] must be sorted ascending by epoch-second timestamp; a sliding window keeps this linear in the number
+ * of readings per 12-hour window rather than quadratic over the whole time frame.
+ */
+ fun nowCastAqiSeries(readings: List<Pair<Long, Double>>): List<Int?> {
+ val windowSeconds = NOWCAST_WINDOW_HOURS * SECONDS_PER_HOUR
+ var start = 0
+ return readings.mapIndexed { index, (time, _) ->
+ // Readings at or before this cutoff fall outside the point's own 12h window, so drop them from the front.
+ val cutoff = time - windowSeconds
+ while (readings[start].first <= cutoff) start++
+ computeNowCastPm25(readings.subList(start, index + 1), time)?.let(::pm25ToAqi)
+ }
+ }
+
private data class Breakpoint(
val concentrationLow: Double,
val concentrationHigh: Double,

diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt
index c967d25a1b..d804f3d1e7 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt
@@ -124,6 +124,63 @@ class AirQualityIndexTest {
assertEquals(20.0, result!!, EPSILON)
}
+ // --- nowCastAqiSeries: historical AQI for the Air Quality graph/table (issue #6381) ---
+
+ @Test
+ fun nowCastAqiSeries_returns_an_entry_per_reading_in_input_order() {
+ val readings = List(4) { i -> (NOW + i * SECONDS_PER_HOUR) to 20.0 }
+ assertEquals(readings.size, AirQualityIndex.nowCastAqiSeries(readings).size)
+ }
+
+ @Test
+ fun nowCastAqiSeries_is_empty_for_no_readings() {
+ assertEquals(emptyList(), AirQualityIndex.nowCastAqiSeries(emptyList()))
+ }
+
+ @Test
+ fun nowCastAqiSeries_leaves_the_first_reading_null_because_one_hour_is_below_the_epa_minimum() {
+ // EPA needs the current hour plus at least one of the two hours before it, so the opening sample can never
+ // have an AQI - a point must never be plotted from insufficient history.
+ val readings = listOf(NOW to 20.0, (NOW + SECONDS_PER_HOUR) to 20.0)
+ assertEquals(listOf(null, AirQualityIndex.pm25ToAqi(20.0)), AirQualityIndex.nowCastAqiSeries(readings))
+ }
+
+ @Test
+ fun nowCastAqiSeries_scopes_each_point_to_its_own_timestamp_not_the_newest() {
+ // Point 1 sees only clean air; point 2 (an hour later) is the first with two populated hours, and the spike
+ // at point 3 must not leak backwards into the earlier points' values.
+ val readings = listOf(NOW to 5.0, (NOW + SECONDS_PER_HOUR) to 5.0, (NOW + 2 * SECONDS_PER_HOUR) to 200.0)
+ val series = AirQualityIndex.nowCastAqiSeries(readings)
+ assertNull(series[0])
+ assertEquals(AirQualityIndex.pm25ToAqi(5.0), series[1])
+ kotlin.test.assertTrue(series[2]!! > series[1]!!, "the spike must raise only its own point: $series")
+ }
+
+ @Test
+ fun nowCastAqiSeries_drops_history_older_than_the_twelve_hour_window() {
+ // A 500 µg/m³ reading 13h before the last point is outside the NowCast window, so the tail must read as the
+ // clean-air pair alone - identical to calling computeNowCastPm25 with the stale reading omitted.
+ val last = NOW + 13 * SECONDS_PER_HOUR
+ val readings = listOf(NOW to 500.0, (last - SECONDS_PER_HOUR) to 20.0, last to 20.0)
+ assertEquals(AirQualityIndex.pm25ToAqi(20.0), AirQualityIndex.nowCastAqiSeries(readings).last())
+ }
+
+ @Test
+ fun nowCastAqiSeries_matches_computeNowCastPm25_at_the_final_point() {
+ val readings = List(6) { i -> (NOW + i * SECONDS_PER_HOUR) to (10.0 + i) }
+ val expected = AirQualityIndex.pm25ToAqi(AirQualityIndex.computeNowCastPm25(readings, readings.last().first)!!)
+ assertEquals(expected, AirQualityIndex.nowCastAqiSeries(readings).last())
+ }
+
+ @Test
+ fun nowCastAqiSeries_yields_null_where_a_gap_breaks_the_epa_minimum_data_rule() {
+ // A 5h gap leaves the resumed reading as the only populated hour in its recent-3h window -> no AQI.
+ val readings = listOf(NOW to 20.0, (NOW + SECONDS_PER_HOUR) to 20.0, (NOW + 6 * SECONDS_PER_HOUR) to 20.0)
+ val series = AirQualityIndex.nowCastAqiSeries(readings)
+ assertEquals(AirQualityIndex.pm25ToAqi(20.0), series[1])
+ assertNull(series[2])
+ }
+
private fun assertEquals(expected: Double, actual: Double, epsilon: Double) {
kotlin.test.assertTrue(abs(expected - actual) < epsilon, "expected $expected but was $actual")
}

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 88c5a407a2..6ad4ffe54b 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -112,7 +112,15 @@
<string name="app_too_old">Application update required</string>
<string name="app_version">Version</string>
<string name="apply">Apply</string>
+ <!-- AQI -->
<string name="aqi">AQI</string>
+ <string name="aqi_good">Good</string>
+ <string name="aqi_hazardous">Hazardous</string>
+ <string name="aqi_moderate">Moderate</string>
+ <string name="aqi_unhealthy">Unhealthy</string>
+ <string name="aqi_unhealthy_sensitive">Unhealthy for Sensitive Groups</string>
+ <string name="aqi_value_with_severity">%1$d (%2$s)</string>
+ <string name="aqi_very_unhealthy">Very Unhealthy</string>
<string name="are_you_sure">Are you sure?</string>
<string name="are_you_sure_change_default">Are you sure you want to change to the default channel?</string>
<string name="audio">Audio</string>

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt
index 5ec22ba7a5..802cc3d03a 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt
@@ -16,23 +16,65 @@
*/
package org.meshtastic.core.ui.component
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.aqi_good
+import org.meshtastic.core.resources.aqi_hazardous
+import org.meshtastic.core.resources.aqi_moderate
+import org.meshtastic.core.resources.aqi_unhealthy
+import org.meshtastic.core.resources.aqi_unhealthy_sensitive
+import org.meshtastic.core.resources.aqi_very_unhealthy
+import org.meshtastic.core.ui.theme.AqiSeverityColors
/**
* EPA AQI severity categories for PM2.5-derived AQI (0-500), per meshtastic/design#54. Mirrors [Co2Severity]'s
* ppm→severity pattern, keyed on AQI value instead.
+ *
+ * The category name is a string resource (not a hardcoded literal like [Co2Severity]'s) because it is rendered next to
+ * the AQI value everywhere the category is shown — the category must never be conveyed by [color] alone.
*/
+@Stable
@Suppress("MagicNumber")
-enum class PmAqiSeverity(val color: Color, val label: String, val range: IntRange) {
- GOOD(Color(0xFF00E400), "Good", 0..50),
- MODERATE(Color(0xFFFFFF00), "Moderate", 51..100),
- UNHEALTHY_SENSITIVE(Color(0xFFFF7E00), "Unhealthy for Sensitive Groups", 101..150),
- UNHEALTHY(Color(0xFFFF0000), "Unhealthy", 151..200),
- VERY_UNHEALTHY(Color(0xFF8F3F97), "Very Unhealthy", 201..300),
- HAZARDOUS(Color(0xFF7E0023), "Hazardous", 301..Int.MAX_VALUE),
+enum class PmAqiSeverity(
+ @Stable val tones: AqiSeverityColors.Tones,
+ @Stable val labelRes: StringResource,
+ val range: IntRange,
+) {
+ GOOD(AqiSeverityColors.Good, Res.string.aqi_good, 0..50),
+ MODERATE(AqiSeverityColors.Moderate, Res.string.aqi_moderate, 51..100),
+ UNHEALTHY_SENSITIVE(AqiSeverityColors.UnhealthySensitive, Res.string.aqi_unhealthy_sensitive, 101..150),
+ UNHEALTHY(AqiSeverityColors.Unhealthy, Res.string.aqi_unhealthy, 151..200),
+ VERY_UNHEALTHY(AqiSeverityColors.VeryUnhealthy, Res.string.aqi_very_unhealthy, 201..300),
+
+ /**
+ * EPA ends the scale at 500; the range saturates instead of stopping there so an out-of-scale value still reads as
+ * the worst category rather than losing its label. `aqiFromPm25` already clamps to 500, so this is defensive only.
+ */
+ HAZARDOUS(AqiSeverityColors.Hazardous, Res.string.aqi_hazardous, 301..Int.MAX_VALUE),
;
+ /** The category color for [darkTheme], legible as body text on `surface` and `surfaceVariant` in both. */
+ fun colorFor(darkTheme: Boolean): Color = if (darkTheme) tones.dark else tones.light
+
+ /**
+ * The category color for the *applied* theme. Resolved from the surface actually in use rather than
+ * `isSystemInDarkTheme()`, so a user who forces light while the system is dark (or vice versa — `AppTheme` takes an
+ * explicit `darkTheme`) still gets the tone that was contrast-checked against the surface they are looking at.
+ */
+ @Composable fun color(): Color = colorFor(MaterialTheme.colorScheme.surface.luminance() < DARK_SURFACE_LUMINANCE)
+
companion object {
+ /**
+ * Midpoint luminance separating a light surface from a dark one. Well clear of both static schemes and of any
+ * plausible dynamic-color surface, which stay near the extremes.
+ */
+ private const val DARK_SURFACE_LUMINANCE = 0.5f
+
/** Returns the [PmAqiSeverity] for the given 0-500 EPA [aqi] value, or null if negative. */
fun fromAqi(aqi: Int): PmAqiSeverity? = when {
aqi < 0 -> null

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
index 1f8081e6c8..035c95f595 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
@@ -212,3 +212,28 @@ object DiscoveryMapColors {
object MessageItemColors {
val Red = Color(0x4DFF0000)
}
+
+/**
+ * Semantic palette for the six EPA AQI categories, following [StatusColors]' per-theme pattern.
+ *
+ * EPA publishes canonical category hex values (`#00E400` Good … `#7E0023` Hazardous), but those are billboard colors
+ * and several are unusable as text: `#FFFF00` Moderate scores 1.01:1 against our light `surface`. So EPA's *hue order*
+ * (green → yellow → orange → red → purple → maroon) is preserved while each category is re-toned per theme — dark tones
+ * for light surfaces, light tones for dark surfaces. `AqiSeverityColorsTest` pins every tone at WCAG AA text contrast
+ * (4.5:1) against both `surface` and `surfaceVariant` (the metric-log card background).
+ *
+ * Color is never the only signal: the category name is always rendered next to the AQI value.
+ */
+@Suppress("MagicNumber")
+object AqiSeverityColors {
+
+ /** The light-theme and dark-theme tone for one AQI category. */
+ data class Tones(val light: Color, val dark: Color)
+
+ val Good = Tones(light = Color(0xFF0F5C29), dark = Color(0xFF8DE0A6))
+ val Moderate = Tones(light = Color(0xFF6B5300), dark = Color(0xFFF5D07A))
+ val UnhealthySensitive = Tones(light = Color(0xFF7A2E00), dark = Color(0xFFFFB07A))
+ val Unhealthy = Tones(light = Color(0xFF8C0F16), dark = Color(0xFFFF9F98))
+ val VeryUnhealthy = Tones(light = Color(0xFF6B2F72), dark = Color(0xFFE0B4E6))
+ val Hazardous = Tones(light = Color(0xFF5E001A), dark = Color(0xFFF6A8C0))
+}

diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/AqiSeverityColorsTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/AqiSeverityColorsTest.kt
new file mode 100644
index 0000000000..8ba760eb36
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/theme/AqiSeverityColorsTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.theme
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import org.meshtastic.core.ui.component.PmAqiSeverity
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * Pins the AQI category palette at WCAG AA text contrast in both themes.
+ *
+ * The AQI category name is drawn as body text on `surface` (node info cards) and on `surfaceVariant` (metric-log
+ * cards), so every tone must clear [MIN_TEXT_CONTRAST] against both. This is why EPA's canonical category hex values
+ * are re-toned rather than used verbatim - EPA yellow `#FFFF00` scores 1.01:1 on our light surface.
+ */
+class AqiSeverityColorsTest {
+
+ private val lightBackgrounds = mapOf("surfaceLight" to surfaceLight, "surfaceVariantLight" to surfaceVariantLight)
+ private val darkBackgrounds = mapOf("surfaceDark" to surfaceDark, "surfaceVariantDark" to surfaceVariantDark)
+
+ @Test
+ fun everyAqiCategoryToneMeetsAaTextContrastInBothThemes() {
+ PmAqiSeverity.entries.forEach { severity ->
+ lightBackgrounds.forEach { (name, background) ->
+ assertAaText(severity.tones.light, background, severity, name)
+ }
+ darkBackgrounds.forEach { (name, background) ->
+ assertAaText(severity.tones.dark, background, severity, name)
+ }
+ }
+ }
+
+ @Test
+ fun lightAndDarkTonesDifferSoNeitherThemeReusesTheOther() {
+ // A copy-pasted tone would silently regress one theme; the whole point of the pair is per-theme re-toning.
+ PmAqiSeverity.entries.forEach { severity ->
+ assertTrue(severity.tones.light != severity.tones.dark, "${severity.name} reuses one tone for both themes")
+ }
+ }
+
+ @Test
+ fun everyCategoryHasItsOwnTonePair() {
+ val tones = PmAqiSeverity.entries.map { it.tones }
+ assertTrue(tones.distinct().size == tones.size, "two AQI categories share a tone pair: $tones")
+ }
+
+ @Test
+ fun forcedThemeSelectsThatThemesTone() {
+ // color() resolves darkTheme from the applied surface rather than the system setting, so a user forcing light
+ // while the OS is dark must still get the light tone that was contrast-checked above.
+ PmAqiSeverity.entries.forEach { severity ->
+ assertTrue(severity.colorFor(darkTheme = false) == severity.tones.light, "${severity.name} forced light")
+ assertTrue(severity.colorFor(darkTheme = true) == severity.tones.dark, "${severity.name} forced dark")
+ }
+ }
+
+ @Test
+ fun staticSurfacesFallOnTheExpectedSideOfTheDarkThresholdColorUses() {
+ // color() classifies via MaterialTheme.colorScheme.surface.luminance() < 0.5f; both schemes must land clearly.
+ assertTrue(
+ surfaceLight.luminance() >= 0.5f,
+ "surfaceLight would be classified dark: ${surfaceLight.luminance()}",
+ )
+ assertTrue(surfaceDark.luminance() < 0.5f, "surfaceDark would be classified light: ${surfaceDark.luminance()}")
+ }
+
+ private fun assertAaText(color: Color, background: Color, severity: PmAqiSeverity, backgroundName: String) {
+ val ratio = contrastRatio(color, background)
+ assertTrue(
+ ratio >= MIN_TEXT_CONTRAST,
+ "${severity.name} on $backgroundName is $ratio:1, below the $MIN_TEXT_CONTRAST:1 AA text minimum",
+ )
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
index d9c50c2db7..b7498faa57 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
@@ -29,6 +29,7 @@ import org.meshtastic.core.model.util.AirQualityIndex
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.aqi
+import org.meshtastic.core.resources.aqi_value_with_severity
import org.meshtastic.core.resources.co2
import org.meshtastic.core.resources.co2_humidity
import org.meshtastic.core.resources.co2_temperature
@@ -60,7 +61,7 @@ private fun nowCastAqi(pm25History: List<Telemetry>): Pair<Int, PmAqiSeverity>?
private fun buildAirQualityCards(
metrics: AirQualityMetrics,
- aqi: Pair<Int, PmAqiSeverity>?,
+ aqiText: String?,
ugm3: String,
ppmUnit: String,
icon: ImageVector,
@@ -77,9 +78,7 @@ private fun buildAirQualityCards(
VectorMetricInfo(Res.string.pm2_5, "$pm $ugm3", icon),
// AQI is derived from the raw PM2.5 reading, so it stacks directly under it — and is only shown when
// that raw reading is present.
- aqi?.let { (aqiValue, severity) ->
- VectorMetricInfo(Res.string.aqi, "$aqiValue (${severity.label})", icon)
- },
+ aqiText?.let { VectorMetricInfo(Res.string.aqi, it, icon) },
),
)
}
@@ -100,13 +99,15 @@ private fun buildAirQualityCards(
)
}
-/** Severity color for a metric's value text, or null to keep the default card color. */
-private fun metricValueColor(label: StringResource, co2Color: Color?, aqiSeverity: PmAqiSeverity?): Color? =
- when (label) {
- Res.string.co2 -> co2Color
- Res.string.aqi -> aqiSeverity?.color
- else -> null
- }
+/**
+ * Severity color for a metric's value text, or null to keep the default card color. The AQI tone is resolved by the
+ * caller because [PmAqiSeverity.color] is `@Composable`.
+ */
+private fun metricValueColor(label: StringResource, co2Color: Color?, aqiColor: Color?): Color? = when (label) {
+ Res.string.co2 -> co2Color
+ Res.string.aqi -> aqiColor
+ else -> null
+}
/**
* Displays air quality info cards for a node showing PM1.0, PM2.5, PM10 and CO₂ values. A card is shown for each metric
@@ -128,18 +129,23 @@ internal fun AirQualityInfoCards(
val ppmUnit = stringResource(Res.string.ppm)
val aqi = nowCastAqi(pm25History)
+ // The category name always accompanies the value, so the AQI card never relies on color alone.
+ val aqiText =
+ aqi?.let { (value, severity) ->
+ stringResource(Res.string.aqi_value_with_severity, value, stringResource(severity.labelRes))
+ }
val icon = MeshtasticIcons.AirQuality
val tempIcon = MeshtasticIcons.Temperature
val humidityIcon = MeshtasticIcons.Humidity
val cards =
- remember(metrics, aqi, ugm3, ppmUnit, icon, tempIcon, humidityIcon, isFahrenheit) {
- buildAirQualityCards(metrics, aqi, ugm3, ppmUnit, icon, tempIcon, humidityIcon, isFahrenheit)
+ remember(metrics, aqiText, ugm3, ppmUnit, icon, tempIcon, humidityIcon, isFahrenheit) {
+ buildAirQualityCards(metrics, aqiText, ugm3, ppmUnit, icon, tempIcon, humidityIcon, isFahrenheit)
}
if (cards.none { it.isNotEmpty() }) return
val co2Color = Co2Severity.fromPpm(metrics.co2 ?: 0)?.color
- val aqiSeverity = aqi?.second
+ val aqiColor = aqi?.second?.color()
- MetricCardFlow(groups = cards, valueColor = { metric -> metricValueColor(metric.label, co2Color, aqiSeverity) })
+ MetricCardFlow(groups = cards, valueColor = { metric -> metricValueColor(metric.label, co2Color, aqiColor) })
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
index 1e46e7e106..837dfa7f0d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
@@ -56,10 +56,13 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.model.util.AirQualityIndex
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_quality_metrics_log
+import org.meshtastic.core.resources.aqi
+import org.meshtastic.core.resources.aqi_value_with_severity
import org.meshtastic.core.resources.co2
import org.meshtastic.core.resources.co2_humidity
import org.meshtastic.core.resources.co2_temperature
@@ -69,10 +72,12 @@ import org.meshtastic.core.resources.pm1_0
import org.meshtastic.core.resources.pm2_5
import org.meshtastic.core.resources.ppm
import org.meshtastic.core.ui.component.Co2Severity
+import org.meshtastic.core.ui.component.PmAqiSeverity
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Green
+import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.Telemetry
@@ -80,35 +85,73 @@ import org.meshtastic.proto.AirQualityMetrics as AirQualityMetricsProto
/**
* Selectable chart metric enum for air quality data series. Internal (not private) so [getValue] can be unit-tested.
+ *
+ * [AQI] is the odd one out: the firmware's `AirQualityMetrics` telemetry carries no AQI field, so it is derived from
+ * the PM2.5 history by [AirQualityIndex] and supplied per sample via [AirQualitySample.aqi] rather than read off a
+ * single [Telemetry]. It is dimensionless, hence the empty [unit].
*/
internal enum class AirQuality(val labelRes: StringResource, val unit: String, val color: Color) {
PM1_0(Res.string.pm1_0, "µg/m³", Blue),
PM2_5(Res.string.pm2_5, "µg/m³", Cyan),
PM10(Res.string.pm10, "µg/m³", Green),
CO2(Res.string.co2, "ppm", Red),
+ AQI(Res.string.aqi, "", Purple),
;
- fun getValue(telemetry: Telemetry): Float? {
- val aq = telemetry.air_quality_metrics ?: return null
+ /**
+ * The plotted value for this series, or null when the sample has no reading for it. [aqi] is the precomputed
+ * NowCast AQI for this sample (see [withNowCastAqi]) and is only consulted by the [AQI] series.
+ */
+ fun getValue(telemetry: Telemetry, aqi: Int? = null): Float? {
// A field that is present-and-zero is a real reading (e.g. a PM sensor in clean air reports 0 µg/m³) and must
// be plotted. The `?.` already excludes genuinely-absent fields (Wire decodes an unset optional uint32 to
// null), so no zero-suppression guard is needed — adding one would discard valid clean-air data.
+ val aq = telemetry.air_quality_metrics
return when (this) {
- PM1_0 -> aq.pm10_standard?.toFloat()
- PM2_5 -> aq.pm25_standard?.toFloat()
- PM10 -> aq.pm100_standard?.toFloat()
- CO2 -> aq.co2?.toFloat()
+ PM1_0 -> aq?.pm10_standard?.toFloat()
+ PM2_5 -> aq?.pm25_standard?.toFloat()
+ PM10 -> aq?.pm100_standard?.toFloat()
+ CO2 -> aq?.co2?.toFloat()
+ AQI -> aqi?.toFloat() // derived, so it arrives alongside the sample rather than inside the telemetry.
}
}
+
+ fun getValue(sample: AirQualitySample): Float? = getValue(sample.telemetry, sample.aqi)
}
+/** An air quality [telemetry] reading paired with the NowCast [aqi] as of its own timestamp (null if unavailable). */
+internal data class AirQualitySample(val telemetry: Telemetry, val aqi: Int?)
+
/**
- * The subset of [candidates] with at least one reading in [telemetries]. The chart only draws series that have data, so
- * the legend must use this rather than the raw selection — otherwise a default-selected series (PM2.5) shows a legend
- * entry on nodes that never report it (issue #5873). Internal so it can be unit-tested.
+ * Pairs every reading in [telemetries] with its historical NowCast AQI, returned sorted **ascending by time** (the
+ * order the chart plots in; the list view uses `asReversed()`).
+ *
+ * Each point's AQI is scoped to its own timestamp, so the graph shows how AQI actually moved rather than smearing the
+ * latest value backwards. Samples whose PM2.5 field is absent contribute no reading and get a null AQI, as do samples
+ * with too little preceding history for EPA's minimum-data rule (issue #6381).
*/
-internal fun metricsWithData(candidates: List<AirQuality>, telemetries: List<Telemetry>): List<AirQuality> =
- candidates.filter { metric -> telemetries.any { metric.getValue(it) != null } }
+internal fun withNowCastAqi(telemetries: List<Telemetry>): List<AirQualitySample> {
+ val ascending = telemetries.sortedBy { it.time }
+ // Keep each PM2.5 reading tied to its position in `ascending` so the returned AQI series can be mapped back onto
+ // the full sample list, gaps included.
+ val pm25Readings =
+ ascending.mapIndexedNotNull { index, telemetry ->
+ telemetry.air_quality_metrics?.pm25_standard?.let { pm25 ->
+ index to (telemetry.time.toLong() to pm25.toDouble())
+ }
+ }
+ val aqiSeries = AirQualityIndex.nowCastAqiSeries(pm25Readings.map { it.second })
+ val aqiByIndex = pm25Readings.mapIndexed { position, (index, _) -> index to aqiSeries[position] }.toMap()
+ return ascending.mapIndexed { index, telemetry -> AirQualitySample(telemetry, aqiByIndex[index]) }
+}
+
+/**
+ * The subset of [candidates] with at least one reading in [samples]. The chart only draws series that have data, so the
+ * legend must use this rather than the raw selection — otherwise a default-selected series (PM2.5) shows a legend entry
+ * on nodes that never report it (issue #5873). Internal so it can be unit-tested.
+ */
+internal fun metricsWithData(candidates: List<AirQuality>, samples: List<AirQualitySample>): List<AirQuality> =
+ candidates.filter { metric -> samples.any { metric.getValue(it) != null } }
private val LEGEND_DATA =
AirQuality.entries.map { metric -> LegendData(nameRes = metric.labelRes, color = metric.color, isLine = true) }
@@ -121,10 +164,16 @@ fun AirQualityMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Uni
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.airQualityMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
- val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveAirQualityMetricsCSV(uri, data) }
+ // AQI has to be derived from the PM2.5 history (the telemetry proto carries no AQI field), so compute it once here
+ // and hand the same samples to the chart, the list and the CSV export.
+ val samples = remember(data) { withNowCastAqi(data) }
+ val newestFirstSamples = remember(samples) { samples.asReversed() }
+
+ // Export the chronological samples, not the newest-first list the LazyColumn renders: every sibling metrics screen
+ // writes CSV rows oldest-first.
+ val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveAirQualityMetricsCSV(uri, samples) }
- val availableMetrics =
- remember(data) { AirQuality.entries.filter { metric -> data.any { metric.getValue(it) != null } } }
+ val availableMetrics = remember(samples) { metricsWithData(AirQuality.entries, samples) }
var selectedMetrics by rememberSaveable { mutableStateOf(setOf(AirQuality.PM2_5, AirQuality.CO2)) }
BaseMetricScreen(
@@ -169,7 +218,7 @@ fun AirQualityMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Uni
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
AirQualityChart(
- telemetries = data.reversed(),
+ samples = samples,
selectedMetrics = selectedMetrics,
vicoScrollState = vicoScrollState,
selectedX = selectedX,
@@ -180,15 +229,15 @@ fun AirQualityMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Uni
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(
- data,
- key = { index, telemetry -> "${telemetry.time}_$index" },
+ newestFirstSamples,
+ key = { index, sample -> "${sample.telemetry.time}_$index" },
contentType = { _, _ -> "air_quality_metrics" },
- ) { _, telemetry ->
+ ) { _, sample ->
AirQualityMetricsCard(
- telemetry = telemetry,
+ sample = sample,
isFahrenheit = state.isFahrenheit,
- isSelected = telemetry.time.toDouble() == selectedX,
- onClick = { onCardClick(telemetry.time.toDouble()) },
+ isSelected = sample.telemetry.time.toDouble() == selectedX,
+ onClick = { onCardClick(sample.telemetry.time.toDouble()) },
)
}
}
@@ -199,7 +248,7 @@ fun AirQualityMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Uni
@Suppress("LongMethod")
@Composable
private fun AirQualityChart(
- telemetries: List<Telemetry>,
+ samples: List<AirQualitySample>,
selectedMetrics: Set<AirQuality>,
vicoScrollState: VicoScrollState,
selectedX: Double?,
@@ -207,10 +256,10 @@ private fun AirQualityChart(
modifier: Modifier = Modifier,
) {
val activeMetrics = AirQuality.entries.filter { it in selectedMetrics }
- val drawnMetrics = metricsWithData(activeMetrics, telemetries)
+ val drawnMetrics = metricsWithData(activeMetrics, samples)
val metricLabels = activeMetrics.associateWith { stringResource(it.labelRes) }
MetricChartScaffold(
- isEmpty = telemetries.isEmpty() || activeMetrics.isEmpty(),
+ isEmpty = samples.isEmpty() || activeMetrics.isEmpty(),
legendData = LEGEND_DATA.filter { ld -> drawnMetrics.any { it.labelRes == ld.nameRes } },
modifier = modifier,
) { modelProducer, chartModifier ->
@@ -221,7 +270,8 @@ private fun AirQualityChart(
val metric = activeMetrics.firstOrNull { it.color == color }
if (metric != null) {
val label = metricLabels[metric] ?: ""
- "$label: ${NumberFormatter.format(value.toFloat(), 0)} ${metric.unit}"
+ // AQI is dimensionless, so trim away the separator that would leave a trailing space.
+ "$label: ${NumberFormatter.format(value.toFloat(), 0)} ${metric.unit}".trimEnd()
} else {
NumberFormatter.format(value.toFloat(), 0)
}
@@ -229,17 +279,20 @@ private fun AirQualityChart(
)
val metricDataSets =
- remember(telemetries, activeMetrics) {
- activeMetrics.map { metric -> telemetries.filter { metric.getValue(it) != null } }
+ remember(samples, activeMetrics) {
+ activeMetrics.map { metric -> samples.filter { metric.getValue(it) != null } }
}
- LaunchedEffect(telemetries, activeMetrics) {
+ LaunchedEffect(samples, activeMetrics) {
modelProducer.runTransaction {
activeMetrics.forEachIndexed { index, metric ->
val metricData = metricDataSets[index]
if (metricData.isNotEmpty()) {
lineModel {
- series(x = metricData.map { it.time }, y = metricData.map { metric.getValue(it) ?: 0f })
+ series(
+ x = metricData.map { it.telemetry.time },
+ y = metricData.map { metric.getValue(it) ?: 0f },
+ )
}
}
}
@@ -285,14 +338,35 @@ private fun AirQualityChart(
}
}
+/**
+ * The NowCast AQI for one log row, derived from that row's preceding PM2.5 history (issue #6381) rather than read from
+ * the telemetry — the proto carries no AQI field. Rows without enough recent history for EPA's minimum-data rule simply
+ * omit this. The category name always accompanies the value, so the severity color is reinforcement, never the only
+ * signal.
+ */
+@Composable
+private fun AqiText(aqi: Int) {
+ val severity = PmAqiSeverity.fromAqi(aqi)
+ val value =
+ severity?.let { stringResource(Res.string.aqi_value_with_severity, aqi, stringResource(it.labelRes)) }
+ ?: aqi.toString()
+ Text(
+ text = "${stringResource(Res.string.aqi)}: $value",
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = severity?.color() ?: MaterialTheme.colorScheme.onSurface,
+ )
+}
+
@Composable
private fun AirQualityMetricsCard(
- telemetry: Telemetry,
+ sample: AirQualitySample,
isSelected: Boolean,
onClick: () -> Unit,
isFahrenheit: Boolean = false,
timeTextOverride: String? = null,
) {
+ val telemetry = sample.telemetry
val aq = telemetry.air_quality_metrics ?: return
val time = timeTextOverride ?: DateFormatter.formatDateTime(telemetry.time.toLong() * MS_PER_SEC)
@@ -319,6 +393,7 @@ private fun AirQualityMetricsCard(
.forEach { (label, value) ->
Text("${stringResource(label)}: $value $ugm3", style = MaterialTheme.typography.bodySmall)
}
+ sample.aqi?.let { AqiText(it) }
}
Column {
aq.co2?.let { co2 ->
@@ -379,15 +454,19 @@ fun PreviewAirQualityCards() {
AirQualityMetricsProto(pm10_standard = 11, pm25_standard = 25, pm100_standard = 33, co2 = 2300),
) to "2023-11-14 22:13",
)
+ // Newest first, matching the list view; AQI is derived rather than read from the proto, so the first row has too
+ // little history to show one.
+ val samples = withNowCastAqi(readings.map { it.first }).asReversed()
+ val timeTexts = readings.map { it.second }.asReversed()
AppTheme {
Surface {
Column {
- readings.forEach { (telemetry, timeText) ->
+ samples.forEachIndexed { index, sample ->
AirQualityMetricsCard(
- telemetry = telemetry,
+ sample = sample,
isSelected = false,
onClick = {},
- timeTextOverride = timeText,
+ timeTextOverride = timeTexts[index],
)
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 1ac74e0784..9a5d2152e0 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -474,12 +474,16 @@ open class MetricsViewModel(
}
}
+ /**
+ * Exports the air quality log. [data] carries the derived NowCast AQI alongside each reading (the telemetry proto
+ * has no AQI field), so the export matches what the graph and table show rather than dropping the derived column.
+ */
@Suppress("CyclomaticComplexMethod")
- fun saveAirQualityMetricsCSV(uri: CommonUri, data: List<Telemetry>) {
+ internal fun saveAirQualityMetricsCSV(uri: CommonUri, data: List<AirQualitySample>) {
exportCsv(
uri = uri,
header =
- "\"date\",\"time\",\"pm10_standard\",\"pm25_standard\",\"pm100_standard\"," +
+ "\"date\",\"time\",\"aqi\",\"pm10_standard\",\"pm25_standard\",\"pm100_standard\"," +
"\"pm10_environmental\",\"pm25_environmental\",\"pm100_environmental\"," +
"\"particles_03um\",\"particles_05um\",\"particles_10um\"," +
"\"particles_25um\",\"particles_50um\",\"particles_100um\"," +
@@ -489,12 +493,13 @@ open class MetricsViewModel(
"\"pm_temperature\",\"pm_humidity\",\"pm_voc_idx\",\"pm_nox_idx\"," +
"\"particles_tps\"\n",
rows = data,
- epochSeconds = { it.time.toLong() },
- ) { t ->
+ epochSeconds = { it.telemetry.time.toLong() },
+ ) { sample ->
// Present-and-zero is a real reading and must be exported (matching the chart/card); only a genuinely
// absent field (null) renders as an empty cell. No zero-suppression guards here.
- val aq = t.air_quality_metrics
- "\"${aq?.pm10_standard ?: ""}\"," +
+ val aq = sample.telemetry.air_quality_metrics
+ "\"${sample.aqi ?: ""}\"," +
+ "\"${aq?.pm10_standard ?: ""}\"," +
"\"${aq?.pm25_standard ?: ""}\"," +
"\"${aq?.pm100_standard ?: ""}\"," +
"\"${aq?.pm10_environmental ?: ""}\"," +

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt
index fe32cd12f5..f97b8c2c4f 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetricsTest.kt
@@ -74,16 +74,81 @@ class AirQualityMetricsTest {
fun `metricsWithData drops selected series that have no reading so the legend is not ever-present`() {
// Issue 5873, CO2-only node: PM2.5 is default-selected but never reported, so it must not survive into the
// legend.
- val co2Only = listOf(telemetry(AirQualityMetrics(co2 = 450)))
+ val co2Only = listOf(sample(AirQualityMetrics(co2 = 450)))
assertEquals(listOf(AirQuality.CO2), metricsWithData(listOf(AirQuality.PM2_5, AirQuality.CO2), co2Only))
}
@Test
fun `metricsWithData keeps a series once it has any reading in the frame`() {
- val mixed = listOf(telemetry(AirQualityMetrics(co2 = 450)), telemetry(AirQualityMetrics(pm25_standard = 8)))
+ val mixed = listOf(sample(AirQualityMetrics(co2 = 450)), sample(AirQualityMetrics(pm25_standard = 8)))
assertEquals(
listOf(AirQuality.PM2_5, AirQuality.CO2),
metricsWithData(listOf(AirQuality.PM2_5, AirQuality.CO2), mixed),
)
}
+
+ // --- AQI series (issue #6381): derived from PM2.5 history, not carried by the telemetry proto ---
+
+ @Test
+ fun `getValue for AQI comes from the sample derived value rather than the telemetry`() {
+ val t = telemetry(AirQualityMetrics(pm25_standard = 25))
+ assertNull(AirQuality.AQI.getValue(t), "AQI is not a proto field, so a bare Telemetry has none")
+ assertEquals(78f, AirQuality.AQI.getValue(AirQualitySample(t, aqi = 78)))
+ assertNull(AirQuality.AQI.getValue(AirQualitySample(t, aqi = null)))
+ }
+
+ @Test
+ fun `getValue for AQI plots a zero AQI instead of suppressing it`() {
+ // Pristine air is AQI 0 - a real value, and the same present-and-zero rule the PM series follow.
+ assertEquals(0f, AirQuality.AQI.getValue(AirQualitySample(telemetry(AirQualityMetrics()), aqi = 0)))
+ }
+
+ @Test
+ fun `withNowCastAqi returns samples ascending by time regardless of input order`() {
+ // The list view feeds newest-first data in; the chart needs oldest-first.
+ val newestFirst = (3 downTo 0).map { hourlyTelemetry(it, pm25 = 10) }
+ assertEquals(listOf(0, 1, 2, 3).map { HOUR * it }, withNowCastAqi(newestFirst).map { it.telemetry.time })
+ }
+
+ @Test
+ fun `withNowCastAqi leaves the earliest sample without an AQI and fills in later ones`() {
+ val samples = withNowCastAqi((0..3).map { hourlyTelemetry(it, pm25 = 10) })
+ assertNull(samples.first().aqi, "one hour of history is below EPA's minimum-data rule")
+ // 2024 EPA table: a steady 10 µg/m³ is AQI 53 (Moderate).
+ assertEquals(listOf(53, 53, 53), samples.drop(1).map { it.aqi })
+ assertEquals(listOf(AirQuality.AQI), metricsWithData(listOf(AirQuality.AQI), samples))
+ }
+
+ @Test
+ fun `withNowCastAqi gives a CO2-only node no AQI so the series is never offered`() {
+ val co2Only =
+ (0..3).map { hour -> Telemetry(time = HOUR * hour, air_quality_metrics = AirQualityMetrics(co2 = 450)) }
+ val samples = withNowCastAqi(co2Only)
+ assertEquals(emptyList(), samples.mapNotNull { it.aqi })
+ assertEquals(emptyList(), metricsWithData(listOf(AirQuality.AQI), samples))
+ }
+
+ @Test
+ fun `withNowCastAqi skips samples whose PM2_5 is absent without shifting the others' values`() {
+ // A CO2-only sample interleaved with PM2.5 samples must not consume an entry of the AQI series.
+ val telemetries =
+ listOf(
+ hourlyTelemetry(0, pm25 = 10),
+ Telemetry(time = HOUR, air_quality_metrics = AirQualityMetrics(co2 = 450)),
+ hourlyTelemetry(2, pm25 = 10),
+ hourlyTelemetry(3, pm25 = 10),
+ )
+ val samples = withNowCastAqi(telemetries)
+ assertNull(samples[1].aqi, "a sample with no PM2.5 reading gets no AQI")
+ assertEquals(53, samples[3].aqi)
+ }
+
+ private fun hourlyTelemetry(hoursFromStart: Int, pm25: Int) =
+ Telemetry(time = HOUR * hoursFromStart, air_quality_metrics = AirQualityMetrics(pm25_standard = pm25))
+
+ private fun sample(aq: AirQualityMetrics, aqi: Int? = null) = AirQualitySample(telemetry(aq), aqi)
+
+ private companion object {
+ const val HOUR = 3600
+ }
}

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Dark_d19fbf1f_0.png
index d0cfdf14a0..409a77dfb1 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Light_b29dc7a7_0.png
index 219d29deaf..2f5b7f2c9d 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityCards_Light_b29dc7a7_0.png differ

Served by rngit 1.5.0 - Generated in 0.23s